Writ

UE4 Trenchbroom

2021-05-01

This is not affiliated with Epic, Unreal, or Trenchbroom in any way, this is just personal project shenanigans

Trenchbroom is a Radiant-type editor. You can think of it as a very lightweight CAD that is great at quickly blocking out models and scenes. If you ever made maps for Doom, Half-Life, Quake, or COD, you’ve used a Radiant before.

Radiants are usually quite old, they’ve mostly been supplanted by level designers doing everything in 3ds, zbrush, or blender. But for those of us who grew up with that scene, it’s a skill that’s hard to beat with more sophisticated tools. Trenchbroom is the most modern Radiant, and the one I use. UE, however, has absolutely no support for the maps created with trenchbroom.

Game Definition

Trenchbroom supports many games, but nothing for UE4. For our purposes the following is a workable game definition, to be placed in <trenchbroom install dir>/games/UE4. For example, mine is C:\Program Files (x86)\trenchbroom\dev\games\UE4\GameConfig.cfg on windows.

{
    version: 3,
    name: "UE4",
    icon: "Icon.png",
    "fileformats": [
        { "format": "Standard", "initialmap": "initial_standard.map" }
    ],
    "filesystem": {
        "searchpath": ".",
        "packageformat": { "extension": "pak", "format": "idpak" }
    },
    "textures": {
        "package": { "type": "directory", "root": "textures" },
        "format": { "extensions": ["jpg", "jpeg", "tga", "png"], "format": "image" },
        "attribute": "_tb_textures"
    },
    "entities": {
        "definitions": [ "trenchbroom.fgd" ],
        "defaultcolor": "0.6 0.6 0.6 1.0",
        "modelformats": [ "bsp, mdl, md2" ]
    },
    "tags": {
        "brush": [],
        "brushface": []
    },
    "faceattribs": {
        "surfaceflags": [],
        "contentflags": []
    }
}

This is all fairly boilerplate, and almost none of it actually affects what we do. The only part that’s notable is the textures.package.root, which defines the name of the directory under which TB will look for textures. This becomes important in the next part.

Texturing

With a radiant, texture images are applied, scaled, and offset per-face or per-brush. Images have to be loaded (in "collections", which just means a directory) from disk before being applied. The setup for this should look like;

game
|- Source
|- Binaries
|- Content
|--|- textures
|--|--|- test1
|--|--|--|- T_sometexture1.png
|--|--|--|- T_texture2.png
|--|--|--|- MI_sometexture1
|--|--|--|- MI_texture2

Where T_ are the actual texture files you create (by whatever means), and MI_ are the material instances that are to be used for the texture ingame. This nomenclature and structure becomes important, because our compilation/export process depends on these conventions - it needs to be able to strip directory names and convert references to textures to material instances so that UE4 knows what to do.

Unfortunately, you’ll still have to manually make the material instances yourself, the export process cannot do that for you. It’s a one-time-per-texture cost, and not very high at that, so it’s acceptable.

Note that the maps/models you create do not have to be located in textures/, and probably shouldn’t be. UE4 has a "global search" option when importing FBX, so these material instances will be found no matter where you create them. TB, however, needs to have them under a specific textures folder.

In TB itself, you’ll have to Reload Texture Collections (default F5, but it’s under the tools menu) to pick up changes to textures, or new textures.

Exporting

Big picture

  1. Export to obj
  2. Import to blender
  3. Make the model as one mesh, rename materials to match what UE4 expects, export to fbx
  4. Auto-import in UE4 should give you precisely the map you made.

Functionally this breaks down into three pieces:

  1. Make a blend file for your model (only needs to be done once)
  2. File->Export to obj
  3. Run->Compile with a step that executes a blender python (bpy) script to do the rest

(highly recommend binding export and compilation to F-keys)

Also worth noting that there’s a task and PR to add obj export to compile, which would be killer - but it hasn’t been merged. Building TB on windows requires Qt which is a paid product and… it’s suddenly not worth it. Just bind the key and hit two keys until this gets done.

Trenchbroom compile script

Working directory should be ${MAP_DIR_PATH}

Parameters should be ${MAP_BASE_NAME} ${GAME_DIR_PATH}

Tool should be a file containing the following; (for windows, bash equivalent would be way simpler)

@echo off

set "blendname=%1%.blend"
set "toolpath=%2%\..\..\tools\mapExport.py"
set "emptyblend=%2%\..\..\tools\empty.blend"

if not exist %blendname% (
	echo copying %emptyblend%
	echo to %blendname%
	copy %emptyblend% %blendname%
)

blender %blendname% -b --python %toolpath%

Map Export BPY

import bpy
import bmesh
import os
import glob

def main():

    if bpy.context.mode != 'OBJECT':
        bpy.ops.object.mode_set()

    deleteAll()
    cleanup()
    importObj()
    joinObjects()
    renameObj()
    renameMaterials()
    deleteEmptyFaces()
    exportFBX()
    
    # save all changes.
    bpy.ops.wm.save_as_mainfile()

    #deleteObj()

def deleteAll():
    bpy.ops.object.select_all(action='SELECT')
    bpy.ops.object.delete() 

# removes all materials, so that there are no name collisions
def cleanup():
    i = 0
    
    for mat in bpy.data.materials:
        mat.name = "_unused" + str(i)
        bpy.data.materials.remove(mat)

# imports the wavefront obj from the same dir
def importObj():
    path = os.path.dirname(bpy.context.blend_data.filepath)
    modelpath = path + "/" + modelname() + ".obj"
    bpy.ops.import_scene.obj(filepath=modelpath)

# renames the joined object to be the same as the filename
def renameObj():
    bpy.data.objects[0].name = modelname()
    
# joins all objects together into one (so that one map = 1 mesh)
def joinObjects():

    ctx = bpy.context.copy()
    ctx['active_object'] = bpy.data.objects[0]

    obs = []
    for ob in bpy.context.scene.objects:
        if ob.type == 'MESH':
            obs.append(ob)

    ctx['active_object'] = obs[0]
    ctx['selected_editable_objects'] = obs
    bpy.ops.object.join(ctx)

# renames materials to not have folder names or blender suffixes
# (so that they map cleanly to uasset materials)
def renameMaterials():

    for obj in bpy.data.objects:
        for i, matslot in list(enumerate(obj.material_slots)):
            if matslot.material:
                
                # remove blender nonsense
                matname = matslot.material.name
                matname = os.path.splitext(matname)[0]
                
                # remove any leading slashes
                slashidx = matname.find('/')
                if(slashidx >= 0):
                    matname = matname[slashidx+1:len(matname)]
                
                # rename from T_ to MI_, so that UE4 import brings in the instance.
                if(matname.startswith('T_')):
                    matname = matname.replace("T_", "MI_", 1)
                    matslot.material.name = matname
                    continue
                
                # and make sure __TB_empty doesnt have a suffix
                if(matname.startswith('__TB_empty')):
                    matslot.material.name = '__TB_empty'
                    continue

def deleteEmptyFaces():

    bpy.ops.object.select_all(action='DESELECT')
    emptyMat = -1

    # find empty material
    for obj in bpy.data.objects:
        for i, matslot in list(enumerate(obj.material_slots)):
            if matslot.material:
                if "__TB_empty" in matslot.name:
                    emptyMat = i
                    break

    if emptyMat == -1:
        return

    for obj in bpy.data.objects:

        bpy.context.view_layer.objects.active = obj
        obj.select_set(True)
        bpy.ops.object.mode_set(mode='EDIT')

        mesh = bmesh.from_edit_mesh(obj.data)
        mesh.select_mode = {"FACE"}

        deadFaces = []

        for face in mesh.faces:
            if face.material_index == emptyMat:
                deadFaces.append(face)

        bmesh.ops.delete(mesh, geom=deadFaces, context='FACES_ONLY')

    bpy.ops.object.mode_set(mode='OBJECT')
                
def exportFBX():
    exportdir = os.path.dirname(bpy.context.blend_data.filepath)
    name = modelname() + ".fbx"
    exportpath = os.path.join(exportdir, name)
    
    bpy.ops.export_scene.fbx(
        filepath=exportpath, 
        use_selection=False,
        apply_scale_options='FBX_SCALE_ALL',
        axis_up="Z"
    )

def deleteObj():
    basepath = os.path.splitext(bpy.context.blend_data.filepath)[0]
    objpath = basepath + ".obj"
    mtlpath = basepath + ".mtl"
    
    os.remove(objpath)
    os.remove(mtlpath)

def modelname():
    filename = bpy.path.basename(bpy.context.blend_data.filepath)
    name = os.path.splitext(filename)[0]
    return name

main()
All site content protected by CC-BY-4.0 license